{
"cells": [
{
"cell_type": "markdown",
"id": "cell-0",
"metadata": {},
"source": [
"# Orbit Maneuvers\n",
"\n",
"This tutorial demonstrates how to model orbit maneuvers in satkit, covering both **impulsive** (chemical) and **continuous** (electric) thrust. We'll work through three progressively complex examples:\n",
"\n",
"1. **Hohmann Transfer** -- the classic two-burn orbit raise using impulsive maneuvers\n",
"2. **Lambert-Targeted Transfer** -- using the Lambert solver to compute transfer burns\n",
"3. **Low-Thrust Orbit Raising** -- continuous electric propulsion spiral-out\n",
"\n",
"## Maneuver Types in SatKit\n",
"\n",
"| Type | Class | Where | Use Case |\n",
"|------|-------|-------|----------|\n",
"| Impulsive | `satstate.add_maneuver()` | Instantaneous dv at a specific time | Chemical propulsion, station-keeping |\n",
"| Continuous | `satproperties` with `thrust` arcs | Sustained acceleration over a time window | Electric propulsion, drag makeup |\n",
"\n",
"## The RTN Coordinate Frame\n",
"\n",
"Both maneuver types support the **RTN** (Radial / Tangential / Normal) coordinate frame, which is the CCSDS OEM/OMM standard for orbit data messages and the canonical name in satkit. The same frame is also known as **RSW** (Vallado's textbook) and **RIC** (older NASA / Clohessy-Wiltshire literature); `sk.frame.RSW` and `sk.frame.RIC` are Python-level aliases that resolve to `sk.frame.RTN`.\n",
"\n",
"The RTN axes are defined relative to the satellite's current orbital state:\n",
"\n",
"- **R (Radial)**: Points away from the Earth center along the position vector\n",
"- **T (Tangential / In-track)**: Perpendicular to the radial direction in the orbital plane, roughly aligned with the velocity vector. For circular orbits this is exactly the velocity direction; for eccentric orbits it differs from velocity by the flight-path angle (use `sk.frame.NTW` if you need \"strictly along velocity\")\n",
"- **N (Normal / Cross-track)**: Completes the right-handed triad; aligned with the orbit angular momentum vector (orbit normal)\n",
"\n",
"Thrust or delta-v vectors specified as `[R, T, N]` in `sk.frame.RTN` are automatically rotated to GCRF by the propagator using the satellite's instantaneous position and velocity. See the **Theory: Maneuver Coordinate Frames** guide for a side-by-side comparison of RTN, NTW, LVLH, and GCRF."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-1",
"metadata": {},
"outputs": [],
"source": [
"import satkit as sk\n",
"import numpy as np\n",
"import matplotlib.pyplot as plt\n",
"import scienceplots # noqa: F401\n",
"plt.style.use([\"science\", \"no-latex\", \"../satkit.mplstyle\"])\n",
"%config InlineBackend.figure_formats = ['svg']\n",
"\n",
"mu = sk.consts.mu_earth\n",
"Re = sk.consts.earth_radius"
]
},
{
"cell_type": "markdown",
"id": "cell-2",
"metadata": {},
"source": [
"## 1. Hohmann Transfer (Impulsive)\n",
"\n",
"A Hohmann transfer is the most fuel-efficient two-impulse transfer between coplanar circular orbits. It consists of:\n",
"\n",
"1. **Burn 1** at perigee: accelerate in-track to enter an elliptical transfer orbit\n",
"2. **Coast** along the transfer ellipse for half an orbit\n",
"3. **Burn 2** at apogee: accelerate in-track to circularize at the target altitude\n",
"\n",
"The Δv for each burn is computed from the vis-viva equation:\n",
"\n",
"$$v = \\sqrt{\\mu \\left(\\frac{2}{r} - \\frac{1}{a}\\right)}$$"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-3",
"metadata": {},
"outputs": [],
"source": [
"# Orbit altitudes\n",
"alt_initial = 400e3 # 400 km LEO\n",
"alt_final = 800e3 # 800 km\n",
"\n",
"r1 = Re + alt_initial\n",
"r2 = Re + alt_final\n",
"a_transfer = (r1 + r2) / 2 # semi-major axis of transfer ellipse\n",
"\n",
"# Circular velocities\n",
"v_circ_1 = np.sqrt(mu / r1)\n",
"v_circ_2 = np.sqrt(mu / r2)\n",
"\n",
"# Transfer orbit velocities (vis-viva)\n",
"v_transfer_perigee = np.sqrt(mu * (2/r1 - 1/a_transfer))\n",
"v_transfer_apogee = np.sqrt(mu * (2/r2 - 1/a_transfer))\n",
"\n",
"# Delta-v for each burn (both in-track / prograde)\n",
"dv1 = v_transfer_perigee - v_circ_1\n",
"dv2 = v_circ_2 - v_transfer_apogee\n",
"transfer_time = np.pi * np.sqrt(a_transfer**3 / mu) # half orbit\n",
"\n",
"print(f\"Initial orbit: {alt_initial/1e3:.0f} km, v = {v_circ_1:.1f} m/s\")\n",
"print(f\"Final orbit: {alt_final/1e3:.0f} km, v = {v_circ_2:.1f} m/s\")\n",
"print(\"\")\n",
"print(f\"Burn 1 (perigee): Δv = {dv1:.2f} m/s in-track\")\n",
"print(f\"Burn 2 (apogee): Δv = {dv2:.2f} m/s in-track\")\n",
"print(f\"Total Δv: {dv1 + dv2:.2f} m/s\")\n",
"print(f\"Transfer time: {transfer_time/60:.1f} min\")"
]
},
{
"cell_type": "markdown",
"id": "cell-4",
"metadata": {},
"source": [
"### Setting Up the Maneuvers\n",
"\n",
"We use `satstate.add_maneuver()` to schedule the two burns. The Δv is specified in the **RTN** frame where the components are `[radial, tangential, normal]`. Both burns are purely tangential (prograde for a circular orbit)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-5",
"metadata": {},
"outputs": [],
"source": [
"# Set up initial state: circular orbit at 400 km, equatorial\n",
"t0 = sk.time(2025, 6, 15, 0, 0, 0)\n",
"pos0 = np.array([r1, 0, 0])\n",
"vel0 = np.array([0, v_circ_1, 0])\n",
"\n",
"sat = sk.satstate(time=t0, pos=pos0, vel=vel0)\n",
"\n",
"# Schedule the two Hohmann burns\n",
"t_burn1 = t0 # burn immediately\n",
"t_burn2 = t0 + sk.duration(seconds=transfer_time) # at apogee\n",
"\n",
"# RTN frame: [radial, tangential, normal]\n",
"sat.add_maneuver(t_burn1, [0, dv1, 0], frame=sk.frame.RTN)\n",
"sat.add_maneuver(t_burn2, [0, dv2, 0], frame=sk.frame.RTN)\n",
"\n",
"print(f\"Maneuvers scheduled: {sat.num_maneuvers}\")\n",
"print(sat)"
]
},
{
"cell_type": "markdown",
"id": "cell-6",
"metadata": {},
"source": [
"### Propagating Through the Transfer\n",
"\n",
"The propagator automatically segments at each maneuver time — no manual bookkeeping required. We propagate past both burns and sample the trajectory."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-7",
"metadata": {},
"outputs": [],
"source": [
"# Propagate well past the second burn to show the circularized orbit\n",
"t_end = t_burn2 + sk.duration(minutes=120)\n",
"\n",
"# Use simple gravity for clean Hohmann geometry\n",
"settings = sk.propsettings(gravity_degree=1)\n",
"\n",
"# Sample trajectory\n",
"npts = 500\n",
"times = [t0 + sk.duration(seconds=float(s)) for s in np.linspace(0, (t_end - t0).seconds, npts)]\n",
"positions = np.zeros((npts, 3))\n",
"altitudes = np.zeros(npts)\n",
"\n",
"for i, t in enumerate(times):\n",
" state = sat.propagate(t, propsettings=settings)\n",
" positions[i] = state.pos / 1e3 # km\n",
" altitudes[i] = np.linalg.norm(state.pos) - Re\n",
"\n",
"# Also propagate without maneuvers for reference\n",
"sat_ref = sk.satstate(time=t0, pos=pos0, vel=vel0)\n",
"pos_ref = np.zeros((npts, 3))\n",
"for i, t in enumerate(times):\n",
" state = sat_ref.propagate(t, propsettings=settings)\n",
" pos_ref[i] = state.pos / 1e3\n",
"\n",
"# Final state check\n",
"final = sat.propagate(t_end, propsettings=settings)\n",
"r_final = np.linalg.norm(final.pos)\n",
"v_final = np.linalg.norm(final.vel)\n",
"print(f\"Final altitude: {(r_final - Re)/1e3:.1f} km (target: {alt_final/1e3:.0f} km)\")\n",
"print(f\"Final velocity: {v_final:.1f} m/s (circular: {v_circ_2:.1f} m/s)\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-8",
"metadata": {},
"outputs": [],
"source": [
"# Plot the transfer\n",
"fig, axes = plt.subplots(1, 2, figsize=(14, 6))\n",
"\n",
"# Left: 2D orbit plot\n",
"ax = axes[0]\n",
"earth = plt.Circle((0, 0), Re/1e3, color='skyblue', alpha=0.3)\n",
"ax.add_patch(earth)\n",
"ax.plot(pos_ref[:, 0], pos_ref[:, 1], '--', alpha=0.4, label=f'Initial orbit ({alt_initial/1e3:.0f} km)')\n",
"ax.plot(positions[:, 0], positions[:, 1], '-', linewidth=1.5, label='Hohmann transfer')\n",
"\n",
"# Mark burn locations with arrows showing dv direction\n",
"burn1_state = sat.propagate(t_burn1 + sk.duration(seconds=1), propsettings=settings)\n",
"burn2_state = sat.propagate(t_burn2 + sk.duration(seconds=1), propsettings=settings)\n",
"\n",
"# Burn 1: prograde at perigee\n",
"b1p = burn1_state.pos / 1e3\n",
"b1v = burn1_state.vel / np.linalg.norm(burn1_state.vel) # unit velocity direction\n",
"ax.plot(b1p[0], b1p[1], 'o', color='green', markersize=10, zorder=5)\n",
"ax.annotate(f'Burn 1: \\u0394v={dv1:.0f} m/s', xy=(b1p[0], b1p[1]),\n",
" xytext=(b1p[0]+500, b1p[1]+800), fontsize=10,\n",
" arrowprops=dict(arrowstyle='->', color='green', lw=1.5))\n",
"\n",
"# Burn 2: prograde at apogee\n",
"b2p = burn2_state.pos / 1e3\n",
"ax.plot(b2p[0], b2p[1], 'o', color='red', markersize=10, zorder=5)\n",
"ax.annotate(f'Burn 2: \\u0394v={dv2:.0f} m/s', xy=(b2p[0], b2p[1]),\n",
" xytext=(b2p[0]-2500, b2p[1]+800), fontsize=10,\n",
" arrowprops=dict(arrowstyle='->', color='red', lw=1.5))\n",
"\n",
"# Draw target orbit circle\n",
"theta = np.linspace(0, 2*np.pi, 200)\n",
"ax.plot(r2/1e3*np.cos(theta), r2/1e3*np.sin(theta), ':', alpha=0.4, color='gray', label=f'Target orbit ({alt_final/1e3:.0f} km)')\n",
"\n",
"ax.set_xlabel('X (km)')\n",
"ax.set_ylabel('Y (km)')\n",
"ax.set_title('Hohmann Transfer')\n",
"ax.set_aspect('equal')\n",
"ax.legend(loc='lower left', fontsize=10)\n",
"\n",
"# Right: altitude vs time\n",
"ax = axes[1]\n",
"dt_min = np.array([(t - t0).seconds / 60 for t in times])\n",
"ax.plot(dt_min, altitudes / 1e3, linewidth=1.5)\n",
"ax.axhline(alt_initial/1e3, ls='--', alpha=0.4, label=f'Initial: {alt_initial/1e3:.0f} km')\n",
"ax.axhline(alt_final/1e3, ls='--', alpha=0.4, label=f'Target: {alt_final/1e3:.0f} km')\n",
"ax.axvline(transfer_time/60, ls=':', alpha=0.3, color='red', label='Burn 2')\n",
"ax.set_xlabel('Time (minutes)')\n",
"ax.set_ylabel('Altitude (km)')\n",
"ax.set_title('Altitude During Transfer')\n",
"ax.legend()\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "cell-9",
"metadata": {},
"source": [
"## 2. Lambert-Targeted Transfer (Impulsive)\n",
"\n",
"The Hohmann transfer works for coplanar circular orbits, but real mission design often requires transferring between arbitrary positions. **Lambert's problem** solves this: given two position vectors and a time of flight (TOF), find the connecting orbit.\n",
"\n",
"Here we use Lambert to design a **LEO-to-GEO transfer** — the classic geostationary transfer orbit. We depart from a 400 km parking orbit and arrive at geostationary altitude (35,786 km), then compare how the total Δv varies with time of flight. The Hohmann transfer emerges as the minimum-Δv solution."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-10",
"metadata": {},
"outputs": [],
"source": [
"# LEO to GEO transfer via Lambert\n",
"r_leo = Re + 400e3\n",
"r_geo = 42164e3 # GEO radius\n",
"v_leo = np.sqrt(mu / r_leo)\n",
"v_geo = np.sqrt(mu / r_geo)\n",
"\n",
"# Departure: 400 km equatorial\n",
"r_depart = np.array([r_leo, 0, 0])\n",
"v_depart = np.array([0, v_leo, 0])\n",
"\n",
"# Arrival: GEO, 180 deg ahead (opposite side of Earth)\n",
"r_arrive = np.array([-r_geo, 0, 0])\n",
"v_arrive_circ = np.array([0, -v_geo, 0])\n",
"\n",
"# Hohmann reference for comparison\n",
"a_hohmann = (r_leo + r_geo) / 2\n",
"hohmann_tof = np.pi * np.sqrt(a_hohmann**3 / mu)\n",
"dv1_hohmann = np.sqrt(mu * (2/r_leo - 1/a_hohmann)) - v_leo\n",
"dv2_hohmann = v_geo - np.sqrt(mu * (2/r_geo - 1/a_hohmann))\n",
"\n",
"# Solve Lambert for a range of TOFs\n",
"tof_hours = np.linspace(3.5, 10, 50)\n",
"dv_total = np.zeros_like(tof_hours)\n",
"\n",
"for i, hours in enumerate(tof_hours):\n",
" tof = hours * 3600\n",
" solutions = sk.lambert(r_depart, r_arrive, tof)\n",
" v1, v2 = solutions[0]\n",
" dv1 = np.linalg.norm(v1 - v_depart)\n",
" dv2 = np.linalg.norm(v_arrive_circ - v2)\n",
" dv_total[i] = dv1 + dv2\n",
"\n",
"# Use optimal TOF (near Hohmann)\n",
"optimal_idx = np.argmin(dv_total)\n",
"optimal_tof = tof_hours[optimal_idx] * 3600\n",
"solutions = sk.lambert(r_depart, r_arrive, optimal_tof)\n",
"v1_transfer, v2_transfer = solutions[0]\n",
"dv_depart = v1_transfer - v_depart\n",
"dv_arrive = v_arrive_circ - v2_transfer\n",
"\n",
"print(f\"Hohmann reference: \\u0394v = {dv1_hohmann + dv2_hohmann:.0f} m/s, TOF = {hohmann_tof/3600:.2f} hours\")\n",
"print(f\"Lambert optimal: \\u0394v = {dv_total[optimal_idx]:.0f} m/s, TOF = {tof_hours[optimal_idx]:.2f} hours\")\n",
"print(f\" Departure \\u0394v: {np.linalg.norm(dv_depart):.0f} m/s\")\n",
"print(f\" Arrival \\u0394v: {np.linalg.norm(dv_arrive):.0f} m/s\")\n",
"\n",
"# Plot dv vs TOF\n",
"fig, ax = plt.subplots(figsize=(8, 5))\n",
"ax.plot(tof_hours, dv_total / 1e3, linewidth=1.5)\n",
"ax.axvline(hohmann_tof/3600, ls='--', color='red', alpha=0.5, label=f'Hohmann TOF ({hohmann_tof/3600:.1f} h)')\n",
"ax.axhline((dv1_hohmann + dv2_hohmann)/1e3, ls=':', color='gray', alpha=0.4, label=f'Hohmann \\u0394v ({(dv1_hohmann+dv2_hohmann)/1e3:.2f} km/s)')\n",
"ax.set_xlabel('Time of Flight (hours)')\n",
"ax.set_ylabel('Total \\u0394v (km/s)')\n",
"ax.set_title('LEO -> GEO Transfer: \\u0394v vs. Time of Flight')\n",
"ax.legend()\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-11",
"metadata": {},
"outputs": [],
"source": [
"# Apply the optimal Lambert transfer as impulsive maneuvers\n",
"t0 = sk.time(2025, 6, 15, 0, 0, 0)\n",
"t_depart = t0\n",
"t_arrive = t0 + sk.duration(seconds=optimal_tof)\n",
"\n",
"sat_lambert = sk.satstate(time=t0, pos=r_depart, vel=v_depart)\n",
"sat_lambert.add_maneuver(t_depart, dv_depart, frame=sk.frame.GCRF)\n",
"sat_lambert.add_maneuver(t_arrive, dv_arrive, frame=sk.frame.GCRF)\n",
"\n",
"# Propagate and sample trajectory\n",
"settings = sk.propsettings(gravity_degree=6)\n",
"t_end = t_arrive + sk.duration(hours=6)\n",
"npts = 400\n",
"dt_arr = np.linspace(0, (t_end - t0).seconds, npts)\n",
"pos_lambert = np.zeros((npts, 3))\n",
"\n",
"for i, dt in enumerate(dt_arr):\n",
" t = t0 + sk.duration(seconds=float(dt))\n",
" state = sat_lambert.propagate(t, propsettings=settings)\n",
" pos_lambert[i] = state.pos / 1e3\n",
"\n",
"# Verify arrival accuracy\n",
"state_transfer = np.array([*r_depart, *v1_transfer])\n",
"result_check = sk.propagate(state_transfer, t0, end=t_arrive, propsettings=settings)\n",
"pos_err = np.linalg.norm(result_check.pos - r_arrive)\n",
"print(f\"Lambert arrival position error: {pos_err:.0f} m\")\n",
"\n",
"final = sat_lambert.propagate(t_end, propsettings=settings)\n",
"r_final = np.linalg.norm(final.pos)\n",
"print(f\"Final orbit radius: {r_final/1e3:.0f} km (GEO = {r_geo/1e3:.0f} km)\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-12",
"metadata": {},
"outputs": [],
"source": [
"# Plot the GTO transfer\n",
"fig, ax = plt.subplots(figsize=(9, 9))\n",
"\n",
"# Draw Earth\n",
"earth = plt.Circle((0, 0), Re/1e3, color='skyblue', alpha=0.3)\n",
"ax.add_patch(earth)\n",
"\n",
"# Draw LEO and GEO circles\n",
"theta = np.linspace(0, 2*np.pi, 200)\n",
"ax.plot(r_leo/1e3*np.cos(theta), r_leo/1e3*np.sin(theta), '--', alpha=0.3, color='gray', label=f'LEO ({400} km)')\n",
"ax.plot(r_geo/1e3*np.cos(theta), r_geo/1e3*np.sin(theta), '--', alpha=0.3, color='gray', label=f'GEO ({35786} km)')\n",
"\n",
"# Transfer trajectory\n",
"ax.plot(pos_lambert[:, 0], pos_lambert[:, 1], '-', linewidth=1.5, label='GTO transfer')\n",
"\n",
"# Mark burns with annotations\n",
"ax.plot(r_depart[0]/1e3, r_depart[1]/1e3, 'o', color='green', markersize=12, zorder=5)\n",
"ax.annotate(f'TLI: \\u0394v={np.linalg.norm(dv_depart)/1e3:.2f} km/s',\n",
" xy=(r_depart[0]/1e3, r_depart[1]/1e3),\n",
" xytext=(r_depart[0]/1e3 + 5000, 8000), fontsize=11,\n",
" arrowprops=dict(arrowstyle='->', color='green', lw=1.5))\n",
"\n",
"ax.plot(r_arrive[0]/1e3, r_arrive[1]/1e3, 'o', color='red', markersize=12, zorder=5)\n",
"ax.annotate(f'Circularization: \\u0394v={np.linalg.norm(dv_arrive)/1e3:.2f} km/s',\n",
" xy=(r_arrive[0]/1e3, r_arrive[1]/1e3),\n",
" xytext=(r_arrive[0]/1e3 + 5000, -8000), fontsize=11,\n",
" arrowprops=dict(arrowstyle='->', color='red', lw=1.5))\n",
"\n",
"ax.set_xlabel('X (km)')\n",
"ax.set_ylabel('Y (km)')\n",
"ax.set_title('LEO -> GEO Transfer (Lambert Solution)')\n",
"ax.set_aspect('equal')\n",
"ax.legend(loc='lower right', fontsize=10)\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "cell-12b",
"metadata": {},
"source": [
"### Multi-Revolution Solutions\n",
"\n",
"For longer times of flight, Lambert's problem admits **multi-revolution** solutions where the satellite completes one or more full orbits before arriving. `sk.lambert()` returns all physically possible solutions automatically.\n",
"\n",
"At long TOFs, the highest-revolution solution can be significantly cheaper than the direct (zero-revolution) transfer — it follows a tighter ellipse closer to the original orbit. The minimum-Δv multi-rev solution approaches the Hohmann limit as the number of revolutions increases."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-12c",
"metadata": {},
"outputs": [],
"source": [
"# Multi-revolution Lambert solutions at various TOFs\n",
"print(f\"Hohmann reference: {dv1_hohmann + dv2_hohmann:.0f} m/s\\n\")\n",
"\n",
"for hours in [20, 30, 40, 50]:\n",
" tof = hours * 3600\n",
" solutions = sk.lambert(r_depart, r_arrive, tof)\n",
" print(f\"TOF = {hours}h: {len(solutions)} solution(s)\")\n",
" best_dv = np.inf\n",
" for i, (v1, v2) in enumerate(solutions):\n",
" dv = np.linalg.norm(v1 - v_depart) + np.linalg.norm(v_arrive_circ - v2)\n",
" if i == 0:\n",
" label = \"0-rev\"\n",
" else:\n",
" label = f\"{(i+1)//2}-rev {'short' if i%2==1 else 'long'}\"\n",
" marker = \" <-- best\" if dv < best_dv else \"\"\n",
" if dv < best_dv:\n",
" best_dv = dv\n",
" print(f\" {label:12s}: {dv:7.0f} m/s{marker}\")\n",
" print()"
]
},
{
"cell_type": "markdown",
"id": "cell-13",
"metadata": {},
"source": [
"## 3. Low-Thrust Orbit Raising (Continuous)\n",
"\n",
"Electric propulsion produces very low thrust over long durations, spiraling outward gradually rather than making discrete burns. This is modeled using `sk.thrust.constant()` with the `satproperties` object.\n",
"\n",
"We'll raise the same 400 km → 800 km orbit as the Hohmann example, but using continuous in-track thrust. The trajectory will be a slow spiral rather than an elliptical transfer, and requires significantly more time."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-14",
"metadata": {},
"outputs": [],
"source": [
"# Low-thrust parameters\n",
"# For a 100 kg satellite with a 50 mN thruster:\n",
"thrust_accel = 5e-4 # m/s^2\n",
"thrust_duration_hours = 120 # ~5 days of continuous thrust\n",
"\n",
"dv_applied = thrust_accel * thrust_duration_hours * 3600\n",
"\n",
"print(f\"Thrust acceleration: {thrust_accel*1e3:.1f} mm/s\\u00b2\")\n",
"print(f\"Thrust duration: {thrust_duration_hours} hours ({thrust_duration_hours/24:.1f} days)\")\n",
"print(f\"Total \\u0394v applied: {dv_applied:.0f} m/s\")\n",
"print(f\"(Hohmann \\u0394v for same altitude change: {dv1 + dv2:.0f} m/s)\")\n",
"print(\"Low-thrust requires more \\u0394v due to gravity losses\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-15",
"metadata": {},
"outputs": [],
"source": [
"# Set up continuous tangential thrust in RTN frame\n",
"t0 = sk.time(2025, 6, 15, 0, 0, 0)\n",
"t_thrust_end = t0 + sk.duration(hours=thrust_duration_hours)\n",
"t_coast_end = t_thrust_end + sk.duration(hours=3)\n",
"\n",
"# Constant tangential acceleration in RTN: [radial, tangential, normal]\n",
"thrust_arc = sk.thrust.constant(\n",
" [0, thrust_accel, 0],\n",
" t0,\n",
" t_thrust_end,\n",
" frame=sk.frame.RTN,\n",
")\n",
"\n",
"props = sk.satproperties(thrusts=[thrust_arc])\n",
"\n",
"# Initial state: 400 km circular (same as Hohmann example)\n",
"state0 = np.array([r1, 0, 0, 0, v_circ_1, 0])\n",
"\n",
"# Use lower-order integrator for robustness with long-duration thrust\n",
"settings = sk.propsettings(\n",
" gravity_degree=1,\n",
" integrator=sk.integrator.rkts54,\n",
")\n",
"\n",
"result = sk.propagate(\n",
" state0, t0,\n",
" end=t_coast_end,\n",
" propsettings=settings,\n",
" satproperties=props,\n",
")\n",
"\n",
"# Sample the trajectory\n",
"npts = 1000\n",
"dt_arr = np.linspace(0, (t_coast_end - t0).seconds, npts)\n",
"time_arr = [t0 + sk.duration(seconds=float(dt)) for dt in dt_arr]\n",
"\n",
"pos_lt = np.zeros((npts, 3))\n",
"alt_lt = np.zeros(npts)\n",
"\n",
"for i, t in enumerate(time_arr):\n",
" s = result.interp(t)\n",
" pos_lt[i] = s[0:3] / 1e3\n",
" alt_lt[i] = np.linalg.norm(s[0:3]) - Re\n",
"\n",
"final_alt = alt_lt[-1]\n",
"print(f\"Final altitude: {final_alt/1e3:.1f} km (target: {alt_final/1e3:.0f} km)\")\n",
"print(f\"Thrust duration: {thrust_duration_hours/24:.1f} days\")\n",
"print(f\"Hohmann transfer time: {transfer_time/60:.0f} minutes\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "cell-16",
"metadata": {},
"outputs": [],
"source": [
"fig, axes = plt.subplots(1, 2, figsize=(14, 6))\n",
"\n",
"# Left: spiral trajectory\n",
"ax = axes[0]\n",
"earth = plt.Circle((0, 0), Re/1e3, color='skyblue', alpha=0.3)\n",
"ax.add_patch(earth)\n",
"ax.plot(pos_lt[:, 0], pos_lt[:, 1], '-', linewidth=0.5, alpha=0.7, label='Low-thrust spiral')\n",
"\n",
"# Mark initial and target orbits\n",
"theta = np.linspace(0, 2*np.pi, 200)\n",
"ax.plot(r1/1e3*np.cos(theta), r1/1e3*np.sin(theta), '--', alpha=0.3, color='gray', label=f'{alt_initial/1e3:.0f} km')\n",
"ax.plot(r2/1e3*np.cos(theta), r2/1e3*np.sin(theta), '--', alpha=0.3, color='gray', label=f'{alt_final/1e3:.0f} km')\n",
"\n",
"ax.set_xlabel('X (km)')\n",
"ax.set_ylabel('Y (km)')\n",
"ax.set_title('Low-Thrust Orbit Raising')\n",
"ax.set_aspect('equal')\n",
"ax.legend(fontsize=10)\n",
"\n",
"# Right: altitude comparison\n",
"ax = axes[1]\n",
"hours = dt_arr / 3600\n",
"ax.plot(hours, alt_lt / 1e3, linewidth=1.5, label='Low-thrust')\n",
"ax.axhline(alt_initial/1e3, ls='--', alpha=0.4, color='gray')\n",
"ax.axhline(alt_final/1e3, ls='--', alpha=0.4, color='gray')\n",
"ax.axvline(thrust_duration_hours, ls=':', alpha=0.3, color='red', label='Thrust cutoff')\n",
"ax.set_xlabel('Time (hours)')\n",
"ax.set_ylabel('Altitude (km)')\n",
"ax.set_title('Altitude vs Time')\n",
"ax.legend()\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "cell-17",
"metadata": {},
"source": [
"## Comparison: Impulsive vs. Low-Thrust\n",
"\n",
"| | Hohmann (Impulsive) | Low-Thrust (Continuous) |\n",
"|---|---|---|\n",
"| Mechanism | Two discrete burns | Sustained tangential acceleration |\n",
"| Δv | Minimum for 2-impulse | Higher due to gravity losses |\n",
"| Transfer time | ~Half orbit period | Hours to days |\n",
"| Trajectory | Elliptical coast arc | Spiral |\n",
"| API | `satstate.add_maneuver()` | `satproperties(thrusts=[...])` |\n",
"| Frame | `sk.frame.RTN` or `sk.frame.GCRF` | `sk.frame.RTN` or `sk.frame.GCRF` |\n",
"\n",
"The choice depends on the propulsion system: chemical rockets deliver high thrust for short durations (impulsive), while electric propulsion provides low thrust over long arcs (continuous)."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}